/*
 * Copyright 2002-2019 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.web.servlet.mvc.method;

import org.springframework.http.HttpMethod;
import org.springframework.lang.Nullable;
import org.springframework.util.PathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.accept.ContentNegotiationManager;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.mvc.condition.*;
import org.springframework.web.util.UrlPathHelper;

import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Set;

/**
 * Request mapping information. Encapsulates the following request mapping conditions:
 * <ol>
 * <li>{@link PatternsRequestCondition}
 * <li>{@link RequestMethodsRequestCondition}
 * <li>{@link ParamsRequestCondition}
 * <li>{@link HeadersRequestCondition}
 * <li>{@link ConsumesRequestCondition}
 * <li>{@link ProducesRequestCondition}
 * <li>{@code RequestCondition} (optional, custom request condition)
 * </ol>
 *
 * @author Arjen Poutsma
 * @author Rossen Stoyanchev
 * @since 3.1
 */
public final class RequestMappingInfo implements RequestCondition<RequestMappingInfo> {

    @Nullable
    private final String name;

    private final PatternsRequestCondition patternsCondition;

    private final RequestMethodsRequestCondition methodsCondition;

    private final ParamsRequestCondition paramsCondition;

    private final HeadersRequestCondition headersCondition;

    private final ConsumesRequestCondition consumesCondition;

    private final ProducesRequestCondition producesCondition;

    private final RequestConditionHolder customConditionHolder;


    public RequestMappingInfo(@Nullable String name, @Nullable PatternsRequestCondition patterns,
                              @Nullable RequestMethodsRequestCondition methods, @Nullable ParamsRequestCondition params,
                              @Nullable HeadersRequestCondition headers, @Nullable ConsumesRequestCondition consumes,
                              @Nullable ProducesRequestCondition produces, @Nullable RequestCondition<?> custom) {

        this.name = (StringUtils.hasText(name) ? name : null);
        this.patternsCondition = (patterns != null ? patterns : new PatternsRequestCondition());
        this.methodsCondition = (methods != null ? methods : new RequestMethodsRequestCondition());
        this.paramsCondition = (params != null ? params : new ParamsRequestCondition());
        this.headersCondition = (headers != null ? headers : new HeadersRequestCondition());
        this.consumesCondition = (consumes != null ? consumes : new ConsumesRequestCondition());
        this.producesCondition = (produces != null ? produces : new ProducesRequestCondition());
        this.customConditionHolder = new RequestConditionHolder(custom);
    }

    /**
     * Creates a new instance with the given request conditions.
     */
    public RequestMappingInfo(@Nullable PatternsRequestCondition patterns,
                              @Nullable RequestMethodsRequestCondition methods, @Nullable ParamsRequestCondition params,
                              @Nullable HeadersRequestCondition headers, @Nullable ConsumesRequestCondition consumes,
                              @Nullable ProducesRequestCondition produces, @Nullable RequestCondition<?> custom) {

        this(null, patterns, methods, params, headers, consumes, produces, custom);
    }

    /**
     * Re-create a RequestMappingInfo with the given custom request condition.
     */
    public RequestMappingInfo(RequestMappingInfo info, @Nullable RequestCondition<?> customRequestCondition) {
        this(info.name, info.patternsCondition, info.methodsCondition, info.paramsCondition, info.headersCondition,
                info.consumesCondition, info.producesCondition, customRequestCondition);
    }

    /**
     * Create a new {@code RequestMappingInfo.Builder} with the given paths.
     *
     * @param paths the paths to use
     * @since 4.2
     */
    public static Builder paths(String... paths) {
        return new DefaultBuilder(paths);
    }

    /**
     * Return the name for this mapping, or {@code null}.
     */
    @Nullable
    public String getName() {
        return this.name;
    }

    /**
     * Return the URL patterns of this {@link RequestMappingInfo};
     * or instance with 0 patterns (never {@code null}).
     */
    public PatternsRequestCondition getPatternsCondition() {
        return this.patternsCondition;
    }

    /**
     * Return the HTTP request methods of this {@link RequestMappingInfo};
     * or instance with 0 request methods (never {@code null}).
     */
    public RequestMethodsRequestCondition getMethodsCondition() {
        return this.methodsCondition;
    }

    /**
     * Return the "parameters" condition of this {@link RequestMappingInfo};
     * or instance with 0 parameter expressions (never {@code null}).
     */
    public ParamsRequestCondition getParamsCondition() {
        return this.paramsCondition;
    }

    /**
     * Return the "headers" condition of this {@link RequestMappingInfo};
     * or instance with 0 header expressions (never {@code null}).
     */
    public HeadersRequestCondition getHeadersCondition() {
        return this.headersCondition;
    }

    /**
     * Return the "consumes" condition of this {@link RequestMappingInfo};
     * or instance with 0 consumes expressions (never {@code null}).
     */
    public ConsumesRequestCondition getConsumesCondition() {
        return this.consumesCondition;
    }

    /**
     * Return the "produces" condition of this {@link RequestMappingInfo};
     * or instance with 0 produces expressions (never {@code null}).
     */
    public ProducesRequestCondition getProducesCondition() {
        return this.producesCondition;
    }

    /**
     * Return the "custom" condition of this {@link RequestMappingInfo}, or {@code null}.
     */
    @Nullable
    public RequestCondition<?> getCustomCondition() {
        return this.customConditionHolder.getCondition();
    }

    /**
     * Combine "this" request mapping info (i.e. the current instance) with another request mapping info instance.
     * <p>Example: combine type- and method-level request mappings.
     *
     * @return a new request mapping info instance; never {@code null}
     */
    @Override
    public RequestMappingInfo combine(RequestMappingInfo other) {
        String name = combineNames(other);
        PatternsRequestCondition patterns = this.patternsCondition.combine(other.patternsCondition);
        RequestMethodsRequestCondition methods = this.methodsCondition.combine(other.methodsCondition);
        ParamsRequestCondition params = this.paramsCondition.combine(other.paramsCondition);
        HeadersRequestCondition headers = this.headersCondition.combine(other.headersCondition);
        ConsumesRequestCondition consumes = this.consumesCondition.combine(other.consumesCondition);
        ProducesRequestCondition produces = this.producesCondition.combine(other.producesCondition);
        RequestConditionHolder custom = this.customConditionHolder.combine(other.customConditionHolder);

        return new RequestMappingInfo(name, patterns,
                methods, params, headers, consumes, produces, custom.getCondition());
    }

    @Nullable
    private String combineNames(RequestMappingInfo other) {
        if (this.name != null && other.name != null) {
            String separator = RequestMappingInfoHandlerMethodMappingNamingStrategy.SEPARATOR;
            return this.name + separator + other.name;
        }
        else if (this.name != null) {
            return this.name;
        }
        else {
            return other.name;
        }
    }

    /**
     * Checks if all conditions in this request mapping info match the provided request and returns
     * a potentially new request mapping info with conditions tailored to the current request.
     * <p>For example the returned instance may contain the subset of URL patterns that match to
     * the current request, sorted with best matching patterns on top.
     *
     * @return a new instance in case all conditions match; or {@code null} otherwise
     */
    @Override
    @Nullable
    public RequestMappingInfo getMatchingCondition(HttpServletRequest request) {
        RequestMethodsRequestCondition methods = this.methodsCondition.getMatchingCondition(request);
        if (methods == null) {
            return null;
        }
        ParamsRequestCondition params = this.paramsCondition.getMatchingCondition(request);
        if (params == null) {
            return null;
        }
        HeadersRequestCondition headers = this.headersCondition.getMatchingCondition(request);
        if (headers == null) {
            return null;
        }
        ConsumesRequestCondition consumes = this.consumesCondition.getMatchingCondition(request);
        if (consumes == null) {
            return null;
        }
        ProducesRequestCondition produces = this.producesCondition.getMatchingCondition(request);
        if (produces == null) {
            return null;
        }
        PatternsRequestCondition patterns = this.patternsCondition.getMatchingCondition(request);
        if (patterns == null) {
            return null;
        }
        RequestConditionHolder custom = this.customConditionHolder.getMatchingCondition(request);
        if (custom == null) {
            return null;
        }

        return new RequestMappingInfo(this.name, patterns,
                methods, params, headers, consumes, produces, custom.getCondition());
    }

    /**
     * Compares "this" info (i.e. the current instance) with another info in the context of a request.
     * <p>Note: It is assumed both instances have been obtained via
     * {@link #getMatchingCondition(HttpServletRequest)} to ensure they have conditions with
     * content relevant to current request.
     */
    @Override
    public int compareTo(RequestMappingInfo other, HttpServletRequest request) {
        int result;
        // Automatic vs explicit HTTP HEAD mapping
        if (HttpMethod.HEAD.matches(request.getMethod())) {
            result = this.methodsCondition.compareTo(other.getMethodsCondition(), request);
            if (result != 0) {
                return result;
            }
        }
        result = this.patternsCondition.compareTo(other.getPatternsCondition(), request);
        if (result != 0) {
            return result;
        }
        result = this.paramsCondition.compareTo(other.getParamsCondition(), request);
        if (result != 0) {
            return result;
        }
        result = this.headersCondition.compareTo(other.getHeadersCondition(), request);
        if (result != 0) {
            return result;
        }
        result = this.consumesCondition.compareTo(other.getConsumesCondition(), request);
        if (result != 0) {
            return result;
        }
        result = this.producesCondition.compareTo(other.getProducesCondition(), request);
        if (result != 0) {
            return result;
        }
        // Implicit (no method) vs explicit HTTP method mappings
        result = this.methodsCondition.compareTo(other.getMethodsCondition(), request);
        if (result != 0) {
            return result;
        }
        result = this.customConditionHolder.compareTo(other.customConditionHolder, request);
        if (result != 0) {
            return result;
        }
        return 0;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }
        if (!(other instanceof RequestMappingInfo)) {
            return false;
        }
        RequestMappingInfo otherInfo = (RequestMappingInfo) other;
        return (this.patternsCondition.equals(otherInfo.patternsCondition) &&
                this.methodsCondition.equals(otherInfo.methodsCondition) &&
                this.paramsCondition.equals(otherInfo.paramsCondition) &&
                this.headersCondition.equals(otherInfo.headersCondition) &&
                this.consumesCondition.equals(otherInfo.consumesCondition) &&
                this.producesCondition.equals(otherInfo.producesCondition) &&
                this.customConditionHolder.equals(otherInfo.customConditionHolder));
    }

    @Override
    public int hashCode() {
        return (this.patternsCondition.hashCode() * 31 +  // primary differentiation
                this.methodsCondition.hashCode() + this.paramsCondition.hashCode() +
                this.headersCondition.hashCode() + this.consumesCondition.hashCode() +
                this.producesCondition.hashCode() + this.customConditionHolder.hashCode());
    }

    @Override
    public String toString() {
        StringBuilder builder = new StringBuilder("{");
        if (!this.methodsCondition.isEmpty()) {
            Set<RequestMethod> httpMethods = this.methodsCondition.getMethods();
            builder.append(httpMethods.size() == 1 ? httpMethods.iterator().next() : httpMethods);
        }
        if (!this.patternsCondition.isEmpty()) {
            Set<String> patterns = this.patternsCondition.getPatterns();
            builder.append(" ").append(patterns.size() == 1 ? patterns.iterator().next() : patterns);
        }
        if (!this.paramsCondition.isEmpty()) {
            builder.append(", params ").append(this.paramsCondition);
        }
        if (!this.headersCondition.isEmpty()) {
            builder.append(", headers ").append(this.headersCondition);
        }
        if (!this.consumesCondition.isEmpty()) {
            builder.append(", consumes ").append(this.consumesCondition);
        }
        if (!this.producesCondition.isEmpty()) {
            builder.append(", produces ").append(this.producesCondition);
        }
        if (!this.customConditionHolder.isEmpty()) {
            builder.append(", and ").append(this.customConditionHolder);
        }
        builder.append('}');
        return builder.toString();
    }


    /**
     * Defines a builder for creating a RequestMappingInfo.
     *
     * @since 4.2
     */
    public interface Builder {

        /**
         * Set the path patterns.
         */
        Builder paths(String... paths);

        /**
         * Set the request method conditions.
         */
        Builder methods(RequestMethod... methods);

        /**
         * Set the request param conditions.
         */
        Builder params(String... params);

        /**
         * Set the header conditions.
         * <p>By default this is not set.
         */
        Builder headers(String... headers);

        /**
         * Set the consumes conditions.
         */
        Builder consumes(String... consumes);

        /**
         * Set the produces conditions.
         */
        Builder produces(String... produces);

        /**
         * Set the mapping name.
         */
        Builder mappingName(String name);

        /**
         * Set a custom condition to use.
         */
        Builder customCondition(RequestCondition<?> condition);

        /**
         * Provide additional configuration needed for request mapping purposes.
         */
        Builder options(BuilderConfiguration options);

        /**
         * Build the RequestMappingInfo.
         */
        RequestMappingInfo build();
    }


    private static class DefaultBuilder implements Builder {

        private String[] paths = new String[0];

        private RequestMethod[] methods = new RequestMethod[0];

        private String[] params = new String[0];

        private String[] headers = new String[0];

        private String[] consumes = new String[0];

        private String[] produces = new String[0];

        @Nullable
        private String mappingName;

        @Nullable
        private RequestCondition<?> customCondition;

        private BuilderConfiguration options = new BuilderConfiguration();

        public DefaultBuilder(String... paths) {
            this.paths = paths;
        }

        @Override
        public Builder paths(String... paths) {
            this.paths = paths;
            return this;
        }

        @Override
        public DefaultBuilder methods(RequestMethod... methods) {
            this.methods = methods;
            return this;
        }

        @Override
        public DefaultBuilder params(String... params) {
            this.params = params;
            return this;
        }

        @Override
        public DefaultBuilder headers(String... headers) {
            this.headers = headers;
            return this;
        }

        @Override
        public DefaultBuilder consumes(String... consumes) {
            this.consumes = consumes;
            return this;
        }

        @Override
        public DefaultBuilder produces(String... produces) {
            this.produces = produces;
            return this;
        }

        @Override
        public DefaultBuilder mappingName(String name) {
            this.mappingName = name;
            return this;
        }

        @Override
        public DefaultBuilder customCondition(RequestCondition<?> condition) {
            this.customCondition = condition;
            return this;
        }

        @Override
        public Builder options(BuilderConfiguration options) {
            this.options = options;
            return this;
        }

        @Override
        public RequestMappingInfo build() {
            ContentNegotiationManager manager = this.options.getContentNegotiationManager();

            PatternsRequestCondition patternsCondition = new PatternsRequestCondition(
                    this.paths, this.options.getUrlPathHelper(), this.options.getPathMatcher(),
                    this.options.useSuffixPatternMatch(), this.options.useTrailingSlashMatch(),
                    this.options.getFileExtensions());

            return new RequestMappingInfo(this.mappingName, patternsCondition,
                    new RequestMethodsRequestCondition(this.methods),
                    new ParamsRequestCondition(this.params),
                    new HeadersRequestCondition(this.headers),
                    new ConsumesRequestCondition(this.consumes, this.headers),
                    new ProducesRequestCondition(this.produces, this.headers, manager),
                    this.customCondition);
        }
    }


    /**
     * Container for configuration options used for request mapping purposes.
     * Such configuration is required to create RequestMappingInfo instances but
     * is typically used across all RequestMappingInfo instances.
     *
     * @see Builder#options
     * @since 4.2
     */
    public static class BuilderConfiguration {

        @Nullable
        private UrlPathHelper urlPathHelper;

        @Nullable
        private PathMatcher pathMatcher;

        private boolean trailingSlashMatch = true;

        private boolean suffixPatternMatch = true;

        private boolean registeredSuffixPatternMatch = false;

        @Nullable
        private ContentNegotiationManager contentNegotiationManager;

        /**
         * Return a custom UrlPathHelper to use for the PatternsRequestCondition, if any.
         */
        @Nullable
        public UrlPathHelper getUrlPathHelper() {
            return this.urlPathHelper;
        }

        /**
         * Set a custom UrlPathHelper to use for the PatternsRequestCondition.
         * <p>By default this is not set.
         *
         * @since 4.2.8
         */
        public void setUrlPathHelper(@Nullable UrlPathHelper urlPathHelper) {
            this.urlPathHelper = urlPathHelper;
        }

        /**
         * Return a custom PathMatcher to use for the PatternsRequestCondition, if any.
         */
        @Nullable
        public PathMatcher getPathMatcher() {
            return this.pathMatcher;
        }

        /**
         * Set a custom PathMatcher to use for the PatternsRequestCondition.
         * <p>By default this is not set.
         */
        public void setPathMatcher(@Nullable PathMatcher pathMatcher) {
            this.pathMatcher = pathMatcher;
        }

        /**
         * Set whether to apply trailing slash matching in PatternsRequestCondition.
         * <p>By default this is set to 'true'.
         */
        public void setTrailingSlashMatch(boolean trailingSlashMatch) {
            this.trailingSlashMatch = trailingSlashMatch;
        }

        /**
         * Return whether to apply trailing slash matching in PatternsRequestCondition.
         */
        public boolean useTrailingSlashMatch() {
            return this.trailingSlashMatch;
        }

        /**
         * Set whether to apply suffix pattern matching in PatternsRequestCondition.
         * <p>By default this is set to 'true'.
         *
         * @see #setRegisteredSuffixPatternMatch(boolean)
         */
        public void setSuffixPatternMatch(boolean suffixPatternMatch) {
            this.suffixPatternMatch = suffixPatternMatch;
        }

        /**
         * Return whether to apply suffix pattern matching in PatternsRequestCondition.
         */
        public boolean useSuffixPatternMatch() {
            return this.suffixPatternMatch;
        }

        /**
         * Set whether suffix pattern matching should be restricted to registered
         * file extensions only. Setting this property also sets
         * {@code suffixPatternMatch=true} and requires that a
         * {@link #setContentNegotiationManager} is also configured in order to
         * obtain the registered file extensions.
         */
        public void setRegisteredSuffixPatternMatch(boolean registeredSuffixPatternMatch) {
            this.registeredSuffixPatternMatch = registeredSuffixPatternMatch;
            this.suffixPatternMatch = (registeredSuffixPatternMatch || this.suffixPatternMatch);
        }

        /**
         * Return whether suffix pattern matching should be restricted to registered
         * file extensions only.
         */
        public boolean useRegisteredSuffixPatternMatch() {
            return this.registeredSuffixPatternMatch;
        }

        /**
         * Return the file extensions to use for suffix pattern matching. If
         * {@code registeredSuffixPatternMatch=true}, the extensions are obtained
         * from the configured {@code contentNegotiationManager}.
         */
        @Nullable
        public List<String> getFileExtensions() {
            if (useRegisteredSuffixPatternMatch() && this.contentNegotiationManager != null) {
                return this.contentNegotiationManager.getAllFileExtensions();
            }
            return null;
        }

        /**
         * Return the ContentNegotiationManager to use for the ProducesRequestCondition,
         * if any.
         */
        @Nullable
        public ContentNegotiationManager getContentNegotiationManager() {
            return this.contentNegotiationManager;
        }

        /**
         * Set the ContentNegotiationManager to use for the ProducesRequestCondition.
         * <p>By default this is not set.
         */
        public void setContentNegotiationManager(ContentNegotiationManager contentNegotiationManager) {
            this.contentNegotiationManager = contentNegotiationManager;
        }
    }

}
